fix: hold terminal input until a fresh pane's agent TUI is ready - #3105
fix: hold terminal input until a fresh pane's agent TUI is ready#3105ikeshavvarshney wants to merge 5 commits into
Conversation
tmux forwards keystrokes to a pane's foreground process the instant attach succeeds, whether or not that process has entered raw mode yet. A full-screen TUI agent (Claude Code, Codex, ...) can take a moment after launch to do so, so fast typing right after a session opens lands as raw, echoed text in the scrollback instead of the agent's input box. Hold input on a pane's first-ever attach for a short grace window, buffering keystrokes via the existing pendingInput mechanism instead of forwarding them early. Only a pane's genuine first attach waits; reattaching to an already-running pane or a second client is unaffected. Fixes Untrivial-ai#3023
|
Thanks for contributing to Agent Orchestrator. This PR is being picked up by the current external contributor on-call pair: If someone is already working on this, please continue as usual. For faster context or live questions, you can also join the AO Discord. Join the session here: Come by if you want to see what is being built, ask questions, or just hang around with the community. |
Addresses review on the terminal input-hold fix (AgentWrapper#3023): - The hold now starts on a pane's first SUCCESSFUL PTY publish instead of at open-request time. A shared one-shot inputGate per pane id replaces the eager deadline: only setPTY's own successful attach can arm it, so a slow Attach or a failed-then-retried attach can no longer let the window expire before the pane is even up. - setPTY now publishes the Stream and returns immediately so copyOut starts reading output right away; releasing buffered input happens on a separate goroutine gated on the shared inputGate, so a boot-time output burst is no longer held hostage behind the input hold. The release goroutine checks the Stream is still current before flushing, so a stale waiter can't write into a Stream a later reattach replaced. - The gate is kept for the Manager's lifetime instead of being time-pruned, so reopening a long-running pane can never be mistaken for a fresh one and re-delayed. Adds regression tests for a slow attach, a failed-then-successful attach, output streaming during the hold, and a reattach long after the window elapsed.
|
Fixed all three. Gate redesigned per your suggestion: shared one-shot inputGate per pane id, armed only by setPTY's own first successful publish — so a slow Attach or a failed-then-retried attach can no longer let the window expire early or start prematurely. setPTY now returns immediately after publishing so copyOut streams output right away; input release moved to a background goroutine gated on inputGate, checking a.pty == p before flushing so a stale waiter can't write into a superseded Stream. Gate is kept for the Manager's lifetime, no more time-pruning, so a long-running reattach never gets re-delayed. Added the four regression tests requested: slow-attach (window measured from attach success, not open request), failed-then-successful attach, output-not-stalled-during-hold, and no-new-delay-on-late-reattach. Full suite (38 tests) passes, gofmt clean, and re-verified live in a real Claude Code session — no leaked/garbled boot text, output streamed immediately. |
…tion-safe Addresses a second review round on the terminal input-hold fix (Untrivial-ai#3023): - Reset the pane's shared input gate on every genuine (re)launch of its agent process (terminal.Manager.ResetInputGate), wired through a new session_manager.InputGateResetter hook called from relaunchSession. Without this, a resume/restart that reuses the runtime handle (ports.RuntimeRestarter) would inherit the exited process's already-open gate and let the replacement TUI's early keystrokes through unheld, reproducing the original race on every resume. - releaseInput now re-reads the live Stream fresh before every single buffered write instead of holding one Stream reference for the whole drain, guarded by a draining flag so only one drain loop runs per attachment at a time. A Stream swap mid-drain (a reattach racing the release) now hands the remainder to whichever Stream is current, instead of risking a write into one this attachment no longer owns. - inputGate.wait now reports whether the gate actually opened or ctx ended first; releaseInputWhenGateOpens abandons on the latter. Manager ../Close cancels its context before marking attachments closed, so a waiter could previously flush buffered keystrokes mid-shutdown. - A genuine (still-current) write failure during the async flush now cancels the attach loop and closes the Stream, so run's blocked copyOut unblocks and the loop actually exits instead of treating a dead Stream as an ordinary drop-and-reattach case. - releaseInput takes a context.Context (AGENTS.md: ctx first-arg for I/O/blocking work) and checks cancellation before draining each chunk. Adds regression tests for: a resume resetting the gate, a Stream swap mid-drain preserving order across the two Streams, Manager.Close abandoning a mid-hold flush promptly, and an async flush failure closing its Stream and ending the attach loop.
|
Fixed all five.
Added the four requested regressions:
Also added the session manager wiring test. Full suite green, |
The GitHub web UI conflict resolution for the sessionLifecycle merge (InputGateResetter + ShellTerminalCloser) concatenated the two method bodies without a separating blank line, which gofmt/goimports requires between a func and the next top-level declaration. Fixes the golangci-lint goimports CI failure on backend/internal/session_manager/manager.go:290.
Summary
Fixes #3023 — terminal accepted keystrokes as soon as
tmux attachsucceeded, before the launched agent's TUI had entered raw mode. Fast typing right after opening a fresh session landed as raw, echoed text in the pane's scrollback instead of the agent's input box, and was unrecoverable there.Root cause
Attach-readiness and agent-readiness are two different things.
tmuxforwards bytes to the pane's foreground process the moment attach succeeds — regardless of whether that process has actually read stdin yet. A full-screen TUI agent (Claude Code, Codex, ...) can take a moment after launch to enter raw mode, and until it does, the underlying line discipline echoes typed characters straight into the terminal's canonical-mode output.Fix
Hold input on a pane's first-ever attach for a short grace window (500ms), reusing the existing
pendingInputbuffering inattachment.write()— no new buffering logic needed. The window is tracked per pane id inManager(inputHoldDeadline), keyed off the first time this daemon process sees that id, so:WithInputGraceWindow(defaults to 500ms, tests disable it for determinism).Changes
backend/internal/terminal/manager.go—inputHoldDeadlinefirst-seen tracking per pane id,WithInputGraceWindowoption, default 500ms grace window.backend/internal/terminal/attachment.go—setPTYwaits out the hold (if any) on a pane's first attach before flippinginputReady, cancellable viactxso shutdown/close is never blocked.backend/internal/terminal/manager_test.go— two new tests (TestServeFreshPaneHoldsInputUntilGraceWindowElapses,TestServeReattachSkipsInputGraceWindow) plusWithInputGraceWindow(0)added to existing timing-sensitive tests to keep them deterministic.Test plan
go build ./...go test ./internal/terminal/...— full suite (33 tests) passes, including the two new grace-window tests.gofmtclean.